Performance et Profiling
Objectifs
- Minimiser les jank (frames 16 ms, 60 FPS)
- Réduire la consommation mémoire
- Rendre la navigation et le scroll fluides
- Optimiser la batterie
C'est quoi le jank ?
Le jank est un ralentissement ou bégaiement visuel qui se produit quand l'application ne peut pas maintenir une fréquence de rafraîchissement fluide.
Explications techniques
- Un smartphone moderne fonctionne à 60 FPS (60 images par seconde)
- Chaque frame doit être complétée en environ 16 ms (1 000 ms ÷ 60 ≈ 16,67 ms)
- Quand une frame prend plus de 16 ms à renderer, l'écran doit attendre avant d'afficher la suivante
- Cet à-coup visuel perceptible par l'utilisateur = jank
Exemple concret
- Scroll fluide : chaque frame < 16 ms → 60 FPS → expérience smooth
- Scroll saccadé : certaines frames > 16 ms → frames perdues → jank visible
Impact utilisateur
- Expérience perçue comme "laggy" ou "non responsive"
- Même quelques frames perdues nuisent à la fluidité perçue
- Cible à viser : < 1% de jank pendant l'utilisation normale
Réduire la consommation de mémoire
Une app gourmande en mémoire ralentit l'appareil entier et risque d'être tuée par le système.
Causes principales de fuite mémoire
- Images non optimisées : charger une image 4K en entier gaspille des MB inutilement
- Rebuilds inutiles : chaque rebuild créé des objets temporaires
- Listes infinies : charger 10 000 items en mémoire au lieu de paginer
- Controllers non disposés : ScrollController, TextEditingController qui ne sont pas fermés
Solutions
- Cacher les images :
cacheHeight: 400, cacheWidth: 400réduit la résolution en mémoire - Utiliser const widgets : évite la création d'objets dupliqués
- Pagination : charger par batches (20 items à la fois) plutôt que tout
- Toujours disposer : les Controllers doivent être fermés dans
dispose()
Cible : < 150 MB pour une app moyenne
Rendre la navigation et le scroll fluides
Un scroll saccadé = mauvaise UX, même s'il ne consomme pas beaucoup de mémoire.
Causes du scroll saccadé
- Calculs lourds pendant le scroll : trier une liste pendant le scroll = jank
- Rebuilds inutiles : des éléments non visibles se reconstruisent
- Animations complexes : trop d'animations simultanées ralentissent le GPU
Solutions
- Séparation des widgets : factoriser les ListItems dans des widgets séparés
- RepaintBoundary : isoler les zones stables pour éviter le redessin complet
- Debounce : attendre que le scroll s'arrête avant de faire des calculs
- Pagination : charger les items sous demande (lazy loading)
- const widgets : moins de rebuilds = moins de travail GPU
Exemple : avec pagination, seuls les ~10 items visibles sont en mémoire. Au scroll, on ajoute les nouveaux items graduellement au lieu de tout recalculer.
Optimiser la batterie
La batterie se décharge rapidement si l'app :
- Garde le CPU actif inutilement (calculs constants)
- Fait des appels réseau fréquents (très gourmand)
- Utilise les animations constantes
Solutions
- Debounce sur input : attendre 500ms au lieu d'appeler l'API à chaque caractère
- Pagination + lazy loading : limiter les appels réseau et la fréquence
- Éviter les animations infinies : une animation qui tourne 24/7 = batterie rapidement vide
- Profiler en production : détecter les appels API qui s'exécutent trop souvent
- Const widgets : moins de CPU pour les rebuilds
Impact : une app qui fait 100 appels API par minute au lieu de 10 peut diviser l'autonomie par 10.
Outils de debugging
Flutter DevTools
flutter pub global activate devtools
devtools
Performance overlay
flutter run --profile --show-performance-overlay
Affiche :
- GPU timeline (jaune)
- UI timeline (bleu)
- FPS actuel (haut à droite)
Widget Inspector
flutter run
# Appuyer sur `w` pour ouvrir Widget Inspector
Permet de :
- Inspecter la hiérarchie de widgets
- Détecter les rebuilds inutiles
- Vérifier les constraints
Règles clés de performance
1. Const partout possible
// MAUVAIS - rebuild à chaque parent rebuild
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
child: Icon(Icons.home),
);
}
}
// BON - Icon est const
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
child: const Icon(Icons.home),
);
}
}
Ce que ça apporte :
- Mauvais : À chaque rebuild du parent,
Iconest recréé en mémoire et redessiné - Bon :
const Iconest compilé une seule fois. Le compilateur réutilise la même instance → 0 rebuild, 0 redessinage - Impact : Moins d'allocations mémoire, moins de travail GPU = scroll plus fluide, batterie préservée
2. Séparation des widgets et clés
// MAUVAIS - ListItem rebuild pour tous les éléments
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index].name));
},
)
// BON - Factoriser ListItem dans un widget séparé
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListItemWidget(item: items[index]);
},
)
// BON - Utiliser key si ordre peut changer
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListItemWidget(key: ValueKey(items[index].id), item: items[index]);
},
)
Ce que ça apporte :
- Mauvais : Tout le
ListTilese rebuild quand n'importe quel item change → toute la liste rebuild - Bon (v1) : Créer un widget séparé = Flutter peut tracker chaque item indépendamment → seul l'item modifié rebuild
- Bon (v2) : Ajouter une
keyunique = Flutter sait quel item c'est même si l'ordre change (ex: suppression, tri) → évite les bugs visuels - Impact : Avec 100 items, au lieu de 100 rebuilds, seul 1 item rebuild → scroll fluide même avec liste longue
3. Images optimisées
// MAUVAIS - charge full résolution
Image.network(url)
// BON - cache dimensions pour réduire mémoire
Image.network(
url,
cacheHeight: 400,
cacheWidth: 400,
)
// BON - placeholder avec FadeInImage
FadeInImage.assetNetwork(
placeholder: 'assets/placeholder.png',
image: imageUrl,
fadeInDuration: Duration(milliseconds: 300),
)
// BON - utiliser WebP ou AVIF pour taille réduite
Ce que ça apporte :
- Mauvais : Charger une image 4K (3MB) pour l'afficher en 400x400px = 8MB en mémoire gaspillés
- Bon (v1) :
cacheHeight/cacheWidth= Flutter redimensionne et cache à la résolution demandée (perte de ~90% mémoire) - Bon (v2) :
FadeInImage= show placeholder rapido pendant téléchargement → UX plus fluide, pas de blanc qui clignote - Bon (v3) : WebP/AVIF = 30-50% plus petits que JPG → telé réseau réduit, batterie économisée
- Impact : Une liste de 20 images en 4K = 160MB en mémoire / Avec optimisation = 15MB
4. Listes avec pagination
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
final _scroll = ScrollController();
final _items = <Product>[];
bool _loading = false;
int _page = 1;
void initState() {
super.initState();
_loadMore();
_scroll.addListener(() {
// Charger plus quand proche de la fin
if (_scroll.position.pixels >=
_scroll.position.maxScrollExtent - 200 && !_loading) {
_loadMore();
}
});
}
Future<void> _loadMore() async {
setState(() => _loading = true);
final next = await fetchPage(_page++);
setState(() => _items.addAll(next));
setState(() => _loading = false);
}
void dispose() {
_scroll.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return ListView.builder(
controller: _scroll,
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _items.length) {
return const Center(child: CircularProgressIndicator());
}
return ListTile(title: Text(_items[index].name));
},
);
}
}
Ce que ça apporte :
- Sans pagination : Charger 10 000 items d'un coup = app freeze pendant 5s, mémoire saturée à 500MB → crash
- Avec pagination : Charger 20 items → user scrolle → détecte position (ligne
_scroll.addListener) → charge 20 de plus - Lazy loading : Seuls les ~10 items visibles sont en mémoire + les ~20 prêts à appear
- Dispose : Fermer le ScrollController évite la memory leak
- Impact : 10 000 items chargés graduellement vs tout à la fois = opérationnel vs inutilisable
5. RepaintBoundary pour zones stables
// MAUVAIS - tout redessine
Column(
children: [
AnimatedContainer(...), // Qui change souvent
ExpensiveWidget(), // Stable mais redessine quand même
],
)
// BON - isoler la zone stabile
Column(
children: [
AnimatedContainer(...),
RepaintBoundary(
child: ExpensiveWidget(),
),
],
)
Ce que ça apporte :
- Mauvais :
AnimatedContainerchange chaque frame (60x par seconde) → toute laColumnredessine →ExpensiveWidget()redessine 60x/sec aussi inutilement - Bon :
RepaintBoundary= créé un cache GPU séparé pourExpensiveWidget→ ignore les animations du parent → redessine 0x si contenu inchangé - Cas typique : Page avec animation de header + liste de produits stable → RepaintBoundary sur liste → scroll/animation fluides
- Impact : Sur une page complexe, 40-50% réduction du travail GPU = moins de jank, moins de batterie
6. Debounce sur input/scroll
// MAUVAIS - setState à chaque keystroke
TextField(
onChanged: (value) {
setState(() => searchQuery = value);
search(value); // Appel API à chaque char
},
)
// BON - debounce avec Timer
Timer? _debounce;
void _onSearchChanged(String query) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
search(query);
});
}
TextField(
onChanged: _onSearchChanged,
)
Ce que ça apporte :
- Mauvais : Taper "flutter" = 7 appels API simultanés pour "f", "fl", "flu", etc. → serveur surchargé, batterie vide, réseau saturé
- Bon :
_debounce?.cancel()annule le timer précédent → attendre 500ms après dernier keypress → 1 seul appel pour "flutter" - Bonus :
setStateappelé 5x au lieu de 7x → CPU économisé, UI ne refresh que pour le résultat final - Cible : Débounce 300-500ms pour search/input utilisateur
- Impact : Au lieu de 100 appels API pour chercher → 1 appel = 99x moins de réseau, batterie x10 mieux
7. Memoization et pré-calcul
// MAUVAIS - recalcule à chaque build
Widget build(BuildContext context) {
final sortedList = items.sorted(); // Recalcul coûteux!
return ListView(children: sortedList);
}
// BON - pré-calculer
late List<Item> _sorted;
void initState() {
super.initState();
_sorted = items.sorted();
}
Widget build(BuildContext context) {
return ListView(children: _sorted);
}
Ce que ça apporte :
- Mauvais :
items.sorted()trié 1000 éléments à chaque rebuild = 10ms CPU utilisé → 60 FPS impossible si rebuild fréquent - Bon : Trier une seule fois en
initState= coût payé au démarrage (2ms OK) →build()appelle_sorteddirectement (0ms) - Quand l'utiliser : Calculs coûteux (sort, filter, map complexe, regex) qui ne changent pas à chaque frame
- Bonus : Meilleure UX = pas de lag pendant scroll car rien à calculer
- Impact : 1000 items triés à chaque frame vs 1x à l'initialisation = 60 FPS maintenus vs 15 FPS saccadé
Monitoring et métadonnées
Profiler en production
import 'package:firebase_performance/firebase_performance.dart';
// Tracer custom
final trace = FirebasePerformance.instance.newTrace('my_trace');
await trace.start();
// ... code à profiler ...
await trace.stop();
Metrics à monitorer
- FPS (cible : 60 FPS sur tous les appareils)
- Mémoire (cible : < 150 MB pour app moyenne)
- Startup time (cible : < 2s pour cold start)
- Jank percentage (cible : < 1%)